Skip to content

test: add proxy burn-in tooling - #455

Merged
freshtonic merged 10 commits into
mainfrom
feat/proxy-burn-in
Aug 21, 2026
Merged

test: add proxy burn-in tooling#455
freshtonic merged 10 commits into
mainfrom
feat/proxy-burn-in

Conversation

@freshtonic

@freshtonic freshtonic commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add a dedicated cipherstash-proxy-burn-in workspace package with deterministic conformance coverage
  • add a timed soak workload with configurable duration, concurrent CRUD, release-profile Proxy build and launch, and one-second RSS sampling
  • adapt the pg-proto type-lab and commerce fixtures into uniquely named public tables with representative EQL v3 integer, text, and JSON domains
  • install EQL when needed, apply fixture DDL through Proxy, and seed encrypted values on a fresh connection after schema and encrypt-config reload
  • use unqualified fixture names so EQL Mapper resolves every workload statement
  • fail conformance unless direct PostgreSQL reads prove ciphertext is stored at rest, then verify typed plaintext is returned through Proxy
  • run the encrypted burn-in in a dedicated PostgreSQL 17 CI job and upload its RSS report
  • document credentials, EQL setup, module responsibilities, encryption-path invariants, conformance runs, soak runs, and generated reports

Soak reliability

  • discover and execute Cargo's exact release artifact, configure it from the direct database target, and reject occupied listeners
  • retain and monitor the owned child process so an unrelated Proxy cannot satisfy readiness
  • bound readiness, operations, and worker shutdown; terminate the child on interruption or drop
  • redact connection credentials from help, diagnostics, and reports
  • serialize fixture mutation with a PostgreSQL advisory lock
  • write reports atomically with terminal status, errors, elapsed time, artifact provenance, operation counts, and non-zero RSS evidence

Verification

  • cargo fmt --all -- --check
  • RUSTC_WRAPPER= cargo test -p cipherstash-proxy-burn-in
  • RUSTC_WRAPPER= cargo clippy -p cipherstash-proxy-burn-in --all-targets -- -D warnings
  • conformance run against the encrypted fixtures
  • isolated two-second release soak using a custom CARGO_TARGET_DIR: 15 encrypted CRUD cycles, zero errors, live RSS report generated
  • occupied-listener regression: soak failed before spawning instead of attaching to the existing listener
  • GitHub reports all six commits as validly signed with james@cipherstash.com and one DCO sign-off each

@tobyhede

Copy link
Copy Markdown
Contributor

Proxy already has benchmark setup that might be worth extending if it doesn't do what you need

tests/benchmark/ (in main, CI-wired via mise run benchmark/benchmark:continuous) — pgbench-driven, black-box, comparative: Proxy vs raw Postgres vs pgbouncer vs pgcat, plaintext vs encrypted.
Plots results to PNG/CSV, CI handles as regression
Protocol-level throughput/latency comparison.

@tobyhede

Copy link
Copy Markdown
Contributor

Review: Standards & Spec (vs origin/main, single commit 179b9dc)

Two-axis review — does the code follow the repo's documented standards, and does it match what the PR description asked for — with every finding independently re-verified against the actual code before reporting.

Worth fixing

1. benches/proxy_crud.rs reimplements what cipherstash-proxy-burn-in already provides

proxy_crud.rs:6-7 re-includes the migration SQL via a relative path:

const SCHEMA: &str = include_str!("../../cipherstash-proxy-burn-in/migrations/0001_schema.sql");
const SEED: &str = include_str!("../../cipherstash-proxy-burn-in/migrations/0002_seed.sql");

but cipherstash-proxy-burn-in::lib already exports these as pub const SCHEMA_MIGRATION / SEED_MIGRATION. Same story for connect() (near-duplicate of database::connect), the CRUD shape (realistic_crud mirrors soak::crud_cycle's insert → read → update → cascading-delete skeleton), and the connection-string defaults (duplicated verbatim between main.rs and proxy_crud.rs).

Adding cipherstash-proxy-burn-in as a dev-dependency of cipherstash-proxy would fix all of these at once — no dependency cycle results, and nearly all of burn-in's deps (clap, serde, tokio, tokio-postgres) are already direct deps of cipherstash-proxy, so it shouldn't meaningfully affect bench build time.

2. README doesn't document the credentials the release Proxy binary needs

The soak workload spawns a real release-profile Proxy binary that inherits the parent process's environment, but the README only says credentials "must already be available in the environment" — no variable names, no pointer to mise.local.toml. packages/showcase/README.md:438-445 sets a good precedent here (lists CS_WORKSPACE_CRN, CS_CLIENT_ACCESS_KEY, CS_DEFAULT_KEYSET_ID, CS_CLIENT_ID, CS_CLIENT_KEY explicitly) — worth matching that so a new contributor can actually get soak running from the README alone.

Minor, not blocking

  • conformance.rs repeats the literal 900_001_i32 nine times rather than binding it once (the repo's convention elsewhere — e.g. random_id() in the integration suite — binds once and reuses).

Checked and cleared (no action needed)

  • The RSS sampler's first tick landing at t≈0 rather than t≈1s is expected tokio::time::interval behavior, and it's actually useful here — it becomes initial_rss_bytes, a genuine baseline reading.
  • --max-rss-growth-mib isn't called out in the PR description's bullets, but it's a natural, opt-in extension for a burn-in/soak tool (the whole point is catching leaks) — not scope creep worth flagging.
  • The migrations' "copied from pg-proto" provenance checks out — pg-proto is a real repo by the same author, already a workspace dependency of this codebase.

@freshtonic

Copy link
Copy Markdown
Contributor Author

Proxy already has benchmark setup that might be worth extending if it doesn't do what you need

Ah, I just looked for a benches dir and missed that. I'll remove mine.

@freshtonic freshtonic changed the title test: add proxy burn-in and CRUD benchmark test: add proxy burn-in tooling Aug 18, 2026
@freshtonic

Copy link
Copy Markdown
Contributor Author

Addressed in f517e6db (with the duplicate CRUD benchmark already removed in af008d7b):

  • documented CS_WORKSPACE_CRN, CS_CLIENT_ACCESS_KEY, CS_DEFAULT_KEYSET_ID, CS_CLIENT_ID, and CS_CLIENT_KEY in the burn-in README, with mise.local.toml setup
  • removed the remaining stale Criterion benchmark documentation
  • bound the conformance fixture ID once and reused it

Verified with formatting, the burn-in package tests, and Clippy with warnings denied.

@freshtonic
freshtonic requested a review from tobyhede August 18, 2026 06:38
@freshtonic
freshtonic force-pushed the feat/proxy-burn-in branch 2 times, most recently from 45768c0 to ff0243b Compare August 19, 2026 05:32

@tobyhede tobyhede left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Correctness at ff0243ba

The two follow-up commits fix the original aggregate type mismatch, add an automated encrypted soak, and verify ciphertext at rest. I rechecked the remaining findings against the new head; these still need attention.

Blocking

1. Release artifact discovery still ignores Cargo configurationsrc/soak.rs:223-253 [repro]

cargo build respects CARGO_TARGET_DIR, build.target-dir, and configured build targets, but spawn_release_proxy() always opens workspace/target/release/cipherstash-proxy. On a shared-target setup, the run either fails after building or launches a stale workspace binary.

Consume Cargo’s JSON compiler-artifact.executable instead of reconstructing the path. That identifies the artifact produced by this exact build and handles target triples as well as custom target directories.

2. An existing listener on 6432 still makes soak measure a dead processsrc/soak.rs:63-116, src/database.rs:183-193 [repro]

The child is reduced to a PID before readiness and is never checked again. With another Proxy already listening, the newly built child exits with Address already in use, readiness and fixture setup use the existing Proxy, and RSS sampling targets the dead child. I reproduced a successful run with 1,701 CRUD cycles, zero final RSS, and zero reported growth.

Preflight the configured bind address, but do not treat that as proof of identity—it remains racy. Pass the Child into the run loop, check try_wait() throughout startup, migration, sampling, and worker completion, and reject zero RSS samples.

3. A timed soak can run indefinitely and runtime failures can erase its reportsrc/soak.rs:89-136, src/database.rs:183-193

Connection, startup-handshake, readiness-query, and CRUD futures have no deadlines. Once the sampling deadline passes, join_next() can wait forever for a wedged worker.

Sampling and worker errors also propagate before the report is written. Add bounded readiness attempts, per-operation timeouts, and a bounded worker-shutdown period. Preserve partial samples and record the terminal error before returning failure.

4. Database credentials are exposed in help and errorssrc/main.rs:31-50, src/database.rs:17-20,183-190 [repro]

Clap prints the complete BURN_IN_*_DATABASE_URL environment value in --help, and connection/readiness errors interpolate the same URL.

Use hide_env_values = true and a parsed connection type with a redacted display form. Never include raw database URLs in diagnostics or reports.

Should fix

5. The aggregate assertion still panics on its intended failure casesrc/conformance.rs:86-97

The new ::bigint cast fixes the unconditional numeric decoding panic. However, if the join loses every row, sum(...)::bigint is NULL and get::<_, i64>() still panics before "joined CRUD result was corrupted" can fire. Decode with try_get::<_, Option<i64>>() and assert Some(4_998).

6. Concurrent runs can deadlock or invalidate one anothermigrations/0001_schema.sql, migrations/0002_seed.sql:3-5, src/soak.rs:156-219

Every run drops and recreates the public fixture tables. The remaining TRUNCATE order is also the reverse of the CRUD insert order. Reordering prevents that specific lock inversion, but concurrent runs would still destroy or contaminate each other’s fixtures and measurements.

Acquire a run-level advisory lock and retain its connection for the entire conformance or soak run; a migration-only lock is insufficient.

7. Report and gate semantics remain inconsistentsrc/soak.rs:113-153

A worker error already short-circuits at join_next(), so ensure!(report.errors == 0) cannot observe one. Zero completed cycles can pass, output-path errors are discovered only after the workload, and soak passed prints before the RSS gate—I reproduced it printing success immediately before exiting 1.

Require at least one completed cycle, include terminal status in partial reports, preflight output with a sibling temporary file and atomic rename, and print success only after all gates pass.

8. The migrated database may not be the spawned Proxy’s upstreamsrc/soak.rs:61-74, src/soak.rs:247-253

--direct-database-url selects the database where EQL is installed and ciphertext is inspected, but the child Proxy is spawned without database arguments and reads ambient CS_DATABASE__*. An override can therefore migrate one database while the child serves another.

Pass or validate the child’s upstream configuration. Record sanitized provenance in the report: artifact hash or commit, concurrency, timestamp, actual elapsed duration, and a redacted database identity.

Additional notes

  • The new CI job closes the encryption-path and execution gaps. It runs only soak and omits --max-rss-growth-mib, so deterministic conformance and retained-growth gating remain local-only. Adding them would strengthen coverage once a stable threshold is established.
  • First-to-last RSS delta matches the README’s “retained growth” wording. The immediate first tick is still a cold baseline; add a warm-up or delayed first sample. Trend fitting is optional rather than a correctness requirement.
  • Add kill_on_drop(true) and signal handling so panics and interrupts do not leave an owned child or discard all samples.
  • Make the wide-text assertion exact, use checked multiplication for --max-rss-growth-mib, and establish a fixture migration/version strategy before the schema evolves again.
  • The updated package’s three unit tests pass. The live subcommands remain the only tests of lifecycle, encryption, and report behavior.

Separately, commits 5c72f021, 31836df6, d64fcba4, and ff0243ba lack the repository-required DCO sign-off, and src/main.rs:61 should say “connections,” not “sessions.”

Signed-off-by: James Sadler <james@cipherstash.com>
Signed-off-by: James Sadler <james@cipherstash.com>
Signed-off-by: James Sadler <james@cipherstash.com>
The burn-in fixtures previously lived in custom schemas, used only native PostgreSQL types, and referenced every table with schema-qualified names. Proxy therefore could not load or resolve the tables and silently treated the workload as unmappable passthrough traffic, so the soak could not detect encryption-path leaks.

Install EQL when its domains are absent, move uniquely named fixtures into public, declare representative integer, text, and JSON columns with EQL v3 domains, and use unqualified table names throughout conformance and soak queries. Apply DDL through one Proxy connection and seed through a fresh connection so the new connection snapshots the reloaded schema and column encryption config.

Seed encrypted values through Proxy rather than directly into PostgreSQL. Conformance now reads the underlying JSON through the direct connection and fails unless representative values have the EQL ciphertext shape, then verifies they decrypt to the original typed values through Proxy. Static regression tests lock down the public-schema, EQL-domain, and unqualified-query requirements.

Signed-off-by: James Sadler <james@cipherstash.com>
Add a dedicated PostgreSQL 17 CI job that decrypts the standard test credentials, starts PostgreSQL, installs EQL, and runs a bounded release-Proxy soak. Keeping this outside the four-version test matrix exercises the leak-sensitive encryption path without multiplying the expensive release build across every supported PostgreSQL version.

Expose the CI command as `mise run test:burn-in`, with configurable duration and concurrency, and upload the RSS report for diagnosis. Move the direct ciphertext-at-rest assertion into shared fixture migration so both conformance and the CI soak fail if workload writes ever fall back to plaintext.

Document each burn-in module’s role and the public-table, unqualified-SQL, fresh-connection, and direct-ciphertext invariants that prevent the workload from silently becoming passthrough traffic.

Signed-off-by: James Sadler <james@cipherstash.com>
Build the release proxy with Cargo JSON output and execute the exact compiler artifact, then configure its upstream from the parsed direct database target. Preflight the listener and continuously verify the owned child so an unrelated proxy can no longer make a dead child look healthy.

Bound readiness, database operations, and worker shutdown; retain partial RSS evidence and terminal errors in an atomic report; require real work and live non-zero RSS before reporting success. Delay the first measurement until after warm-up and terminate the child on interruption or drop.

Parse connection settings into a redacting type, hide environment defaults from CLI help, and acquire a run-wide advisory lock so concurrent burn-ins cannot corrupt shared fixtures. Also make aggregate NULL handling explicit, compare wide values exactly, use checked RSS-limit conversion, and truncate fixtures in dependency order.

Signed-off-by: James Sadler <james@cipherstash.com>
@freshtonic

Copy link
Copy Markdown
Contributor Author

Addressed the latest review in c9dbd6f9:

  • Cargo JSON now supplies the exact release executable; custom target directories are supported.
  • The soak rejects occupied listeners, retains its spawned child, checks try_wait() throughout, and rejects zero RSS samples.
  • Readiness, individual operations, migration, and worker shutdown are bounded; interruption/drop terminates the child.
  • Reports are written atomically and include terminal status/error, actual elapsed time, artifact/source/database provenance, operations, errors, and partial RSS evidence.
  • Database arguments are parsed into a credential-redacting type, hidden from environment-backed help text, and the spawned Proxy is configured from the direct target.
  • A run-wide PostgreSQL advisory lock prevents concurrent fixture mutation; truncation follows dependency order.
  • The aggregate assertion handles SQL NULL explicitly, the wide-text assertion is exact, RSS conversion is checked, and CLI wording says connections.

Regression coverage includes credential redaction, custom Cargo artifact discovery, report validity, occupied-listener rejection, and an end-to-end custom-target soak. The burn-in package tests and Clippy pass. I also rewrote the stack: GitHub reports every commit as validly signed by james@cipherstash.com, with exactly one matching DCO sign-off.

@freshtonic
freshtonic requested a review from tobyhede August 19, 2026 06:40

@tobyhede tobyhede left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: burn-in tooling at c5cc4f44

I re-verified every finding against the code and against completed CI runs. Two candidate
findings did not hold and are listed under "Checked and cleared".

Should fix

1. The burn-in builds Proxy in release mode two times.github/workflows/test.yml:93-97, packages/cipherstash-proxy-burn-in/src/soak.rs:306-326

mise run proxy:up calls build:binary, which builds with --target x86_64-unknown-linux-gnu
(mise.toml:680). build_release_proxy() builds the same package with no --target. The two
builds write to different directories and share no artifacts.

Each build takes about 3 minutes 45 seconds. The release profile sets codegen-units = 1
(Cargo.toml:35-37). The cache does not help: a warm run of the same build took 3 minutes 39
seconds. Earlier burn-in runs took 4 to 6 minutes, and the new conformance step has not yet run in
CI. With that step the job needs about 10 minutes of the 15-minute budget.

Pass the same --target in build_release_proxy(), or start the soak from the binary that
build:binary already produced. Do not increase timeout-minutes.

2. schema_changed is a write-once latchpackages/cipherstash-proxy/src/postgresql/context/mod.rs:565-575

set_schema_changed() only writes true. No code writes false. After a connection sends any DDL,
reload_schema_if_changed() therefore becomes an unconditional reload for the rest of that
connection's life.

Each reload sends ReloadCommand::DatabaseSchema and awaits the response (context/mod.rs:771-789).
The handler reloads the schema and the encrypt config (proxy/mod.rs:116-120). One global task
serves all connections, so these reloads become serial across connections.

This defect is already on main: the same unguarded reload runs for non-passthrough connections
(main's backend.rs:283-285) and for Code::Sync (frontend.rs:303). This PR adds one more case.
A psql session against a database with no encrypted columns now pays a schema reload and an
encrypt-config reload on every statement after its first DDL.

Do not revert backend.rs:175-181; that block fixes a real problem. Clear the flag after a
successful reload. An AtomicBool::swap(false, ...) also removes the read-then-reload race.

3. The burn-in CI job cannot reach the new passthrough branch.github/workflows/test.yml:88-90

postgres:setup applies tests/sql/schema.sql, which creates EQL-domain columns
(tests/sql/schema.sql:38-62). Proxy infers the encrypt config from the schema
(proxy/encrypt_config/manager.rs:88-91). Each connection snapshots that config when it opens, and
is_passthrough() reads the snapshot (context/mod.rs:798-800). The soak Proxy therefore starts
with a non-empty config, and backend.rs:179-181 never runs in that job.

The unit test passthrough_reloads_changed_schema_on_ready_for_query (backend.rs:921-948) does
cover the branch. No test covers the full path from a bare database. The comment at
database.rs:176-178 states the DDL round trip works "including when this database had no encrypted
columns at boot", and nothing proves that end to end.

Run one burn-in against a database that has no encrypted columns at boot.

Nits

  • soak.rs:141-146 gives the whole bootstrap the 10-second per-operation budget, but
    conformance.rs:22 gives migrate no timeout. Measured migration time in CI is about 300
    milliseconds, so the budget is safe today. Make the two paths consistent and give the migration its
    own named constant.
  • tests/sql/eql-domains-uninstall.sql:14 drops each domain without IF EXISTS. All 52
    public.eql_v3_* domains are AS jsonb, so CASCADE cannot reach a sibling domain and the loop
    cannot fail today. If it ever fails, the DO block rolls back every drop, and psql exits 0
    because no task sets ON_ERROR_STOP. The teardown would then leave stale domains and report
    success. Add IF EXISTS, and set ON_ERROR_STOP=1 on the teardown task.
  • On the worker-shutdown-timeout path, soak.rs:213 returns before soak.rs:221-222 refreshes the
    counters. The report can show operation and error counts that are up to 16 seconds old.
    refresh_rss_summary() solves this for RSS; the counters have no equivalent.
  • Item 7 of my earlier review is still open. ensure!(report.errors == 0) (soak.rs:414) cannot
    observe an error: both increments are followed by return Err(...) (soak.rs:173-174,
    soak.rs:180-181), and the error propagates at soak.rs:223 before validate_report runs. The
    errors field itself is useful, because the sampling loop copies it into the report and the report
    is written on the failure path. Keep the field. The assertion is harmless as an invariant guard, so
    no action is needed unless you make worker errors non-fatal.

Checked and cleared

  • conformance.rs:125 expect_err(...): the message gives meaningful context, which matches
    CLAUDE.md. The exit status and the skipped checks are the same as with an anyhow error.
  • ids cannot overflow i32. It starts at 1,000,000, and i32::try_from returns an error instead of
    wrapping. The limit is about 2.1e9 cycles.
  • Sampling and worker deadlines are correctly ordered. A final CRUD cycle can run up to
    OPERATION_TIMEOUT (10 s) past the deadline, and WORKER_SHUTDOWN_TIMEOUT is 15 s.
  • No report is written when the run fails before soak.rs:88. preflight_output() and
    if-no-files-found: warn handle this deliberately.
  • The ZeroKMS and CTS handshake runs at Proxy startup (proxy/mod.rs:58-62), so READY_TIMEOUT
    covers it, not the migration timeout. Measured init time was 667 ms.
  • find_proxy_artifact, the atomic report write, kill_on_drop plus run_until_interrupted, and
    the RSS growth and peak helpers all read correctly.

@freshtonic
freshtonic requested a review from tobyhede August 20, 2026 04:23
Exercise the burn-in from a database with no encrypted columns at Proxy startup so CI proves that passthrough DDL triggers schema and encrypt-config reload before encrypted fixture seeding.

Replace the schema-changed write-once lock with an atomic dirty flag that is consumed by a successful reload and restored when reload delivery fails. Route both simple and extended query completion through the same one-shot reload path, preventing every later statement on a DDL connection from serially reloading global state.

Apply the named migration timeout consistently to conformance and soak runs, snapshot worker counters after timed-out workers are cancelled, and make EQL teardown stop on SQL errors. Regression tests pin the one-reload behavior, bare-database CI setup, teardown strictness, and counter snapshots.

Signed-off-by: James Sadler <james@cipherstash.com>
@freshtonic

Copy link
Copy Markdown
Contributor Author

Addressed in bc94d74e:

  • Replaced the write-once schema_changed lock with an AtomicBool dirty flag. Both simple and extended query completion now consume the flag through reload_schema_if_changed(), and a failed reload restores it for retry. The async regression test proves two checks after one DDL emit exactly one reload.
  • Changed the CI burn-in setup to start from a guaranteed bare database: download EQL, run strict idempotent teardown, then let the soak install EQL before its owned Proxy starts and create encrypted fixture columns through that initially-passthrough Proxy.
  • Introduced one named migration timeout and applied it to both conformance and soak fixture migration.
  • Cancel and drain timed-out workers before snapshotting operation/error counters, so the failure report contains the terminal counts rather than the previous sampling tick.
  • Added ON_ERROR_STOP=1 to EQL teardown/setup cleanup commands so a failed drop cannot report success.

Two observations did not require code changes:

  • The actual PR merge ref does not build Proxy twice. This job calls postgres:up, not proxy:up; postgres:up only starts PostgreSQL. The soak's Cargo JSON build is the sole release Proxy build.
  • The authoritative EQL 3.0.4 uninstall script already uses DROP SCHEMA IF EXISTS ... CASCADE for both EQL schemas; there is no checked-in tests/sql/eql-domains-uninstall.sql or non-idempotent 52-domain loop in this repository. The actionable teardown issue was the missing ON_ERROR_STOP, fixed above.

Verification: 133 Proxy unit tests, 8 burn-in tests plus its binary/doc tests, formatting, and Clippy with warnings denied all pass. The commit is SSH-signed and DCO-signed as james@cipherstash.com.

The CI burn-in reached encrypted fixture seeding but tokio-postgres prepared the INSERT against EQL domain parameter types. Native Rust values cannot serialize directly as those JSON-backed domains, so the job stopped before the soak and never exercised the encryption path.

Send the seed INSERT with explicitly typed native parameters via query_typed. Proxy can then infer and encrypt each destination column while tokio-postgres encodes the original integer, text, bytea, array, and JSON values using their native wire formats. Add a regression test that pins the complete parameter-type contract.

Also propagate actual schema and encrypt-config manager reload outcomes through ReloadCommand. A manager load failure is now acknowledged as false, causing the connection's atomic dirty flag to be restored for a later retry instead of being cleared merely because the response channel remained open. Cover the failed acknowledgement path alongside the existing one-shot success test.

Signed-off-by: James Sadler <james@cipherstash.com>
@freshtonic

Copy link
Copy Markdown
Contributor Author

Addressed the two remaining material gaps in signed commit 2f92d661:

  • The burn-in seed path now uses query_typed with native PostgreSQL parameter types. This avoids attempting to serialize Rust values directly as JSON-backed EQL domains while preserving Proxy column inference and encryption. A local two-second release soak completed 16 encrypted CRUD cycles, passed direct ciphertext-at-rest validation, and captured non-zero RSS.
  • Schema and encrypt-config managers now return their actual reload outcomes through ReloadCommand. Failed manager reloads are acknowledged as failures, so reload_schema_if_changed() restores the atomic dirty flag and retries later. Regression coverage exercises both one-shot success and failed-reload retention.

Verification: 9 burn-in tests, 134 Proxy unit tests (serialized to avoid the existing environment-test race), formatting, Clippy with warnings denied, and the end-to-end encrypted soak all pass. Every item from review 4968785141 remains addressed; re-review is already requested from @tobyhede.

A Proxy started against a database without encrypted columns enters passthrough mode. Although the frontend detected fixture DDL, the backend's passthrough fast path forwarded ReadyForQuery and returned before publishing the schema reload. The next burn-in connection therefore inherited the empty startup snapshot and sent native values directly to EQL domains.

Reload changed schemas before forwarding ReadyForQuery even in passthrough mode. This preserves PostgreSQL's readiness boundary: once the DDL client observes completion, a newly opened connection can load the refreshed schema and encrypt configuration. Add a backend regression test that proves reload acknowledgement precedes the forwarded readiness message.

Replace drain-and-collect with mem::take in MessageBuffer to satisfy the drain_collect lint enforced by the CI Rust toolchain across every PostgreSQL matrix job.

Signed-off-by: James Sadler <james@cipherstash.com>
The CI runner's Rust 1.98 Clippy now rejects format! calls whose strings have no interpolation. These pre-existing multitenant test cases caused every PostgreSQL matrix job to fail before tests could run, masking validation of the burn-in changes.

Construct the four static SQL strings with to_string instead. This preserves the invalid-input fixtures exactly while allowing the repository-wide warning gate to complete on the CI toolchain.

Signed-off-by: James Sadler <james@cipherstash.com>
@freshtonic

Copy link
Copy Markdown
Contributor Author

Follow-up verification is complete.

The two material gaps are now closed:

  • 2f92d661 sends seed parameters as native PostgreSQL types so Proxy can map and encrypt them, and propagates real schema/encrypt-config reload outcomes so failed reloads remain dirty for retry.
  • 8661b7df handles ReadyForQuery before the passthrough backend returns, so a Proxy started against a bare database publishes fixture DDL before the next connection snapshots schema/config. The regression test asserts reload acknowledgement occurs before readiness is forwarded.

d54fdfd6 also resolves the Rust 1.98 Clippy failures that were masking the matrix.

Verification on the latest head:

  • encrypted bare-database burn-in: passed
  • PostgreSQL 14, 15, 16, and 17 jobs: passed
  • performance regression check: passed
  • every PR commit is signed and uses james@cipherstash.com

@tobyhede the requested re-review remains active; GitHub will retain CHANGES_REQUESTED until a reviewer submits a new review.

@tobyhede tobyhede left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — verified against head d54fdfd6

My earlier pass ran against a stale local checkout (c5cc4f44). That checkout is a divergent branch, not an ancestor of this PR, so several findings cited code that this PR never contained. I have re-verified every finding against the PR head. I withdraw five findings at the end of this review.


Must fix

1. Extended-protocol DDL does not reload the schema

packages/cipherstash-proxy/src/postgresql/frontend.rs:303

bc94d74e made the schema-changed flag a consuming read. That fixes the sticky flag I raised earlier, but it exposes an ordering defect in the frontend.

Proxy sets the flag in parse_handler (frontend.rs:846), when it parses the statement. Proxy consumes the flag in rewrite (frontend.rs:303), when the client Sync arrives. take_schema_changed clears the flag as it reads it.

A client that uses the extended protocol prepares the statement first. tokio_postgres shows the shape clearly: ToStatement for str always calls client.prepare(), and prepare sends Parse, Describe and Sync (prepare.rs:128-129). So the client sends two exchanges:

  1. Parse / Describe / Sync — Proxy sets the flag at Parse, then consumes it at Sync and reloads. The DDL has not run yet. The reload reads a catalog without the new table.
  2. Bind / Execute / Sync — the DDL runs. The flag is already clear, so Proxy does not reload.

The backend ReadyForQuery (backend.rs:294) also finds a clear flag. Proxy therefore misses the DDL until the background reload, 60 seconds later by default.

Before this PR the flag was sticky, so the next statement reloaded again after the DDL. That behaviour hid the defect. The take semantics remove it. This also defeats the guarantee that 8661b7df states in its own comment, for every extended-protocol client.

Affected: JDBC in prepared mode, tokio_postgres query/execute, sqlx.
Not affected: the simple query protocol, which sends no Sync. The burn-in uses the simple query protocol only, so the new tests cannot find this.

Suggested fix: reload on the backend ReadyForQuery only, and remove the frontend.rs:303 call. The backend path runs after the statement completes, so it reads a catalog that contains the DDL.

Regression test

Add this to packages/cipherstash-proxy-integration/src/schema_change.rs. The connection that runs the DDL cannot show the defect, because collect_ddl adds the table to that connection's own editable TableResolver. Only a later connection reads the reloaded global state, so the test opens a second connection.

use crate::common::{connect_with_tls, query_direct_by, random_id, trace, PROXY};
use tokio_postgres::Client;

/// Drops a fixture table through Proxy.
async fn drop_table(client: &Client, table: &str) {
    client
        .simple_query(&format!("DROP TABLE IF EXISTS {table}"))
        .await
        .unwrap();
}

/// Reads the stored value directly from PostgreSQL and asserts that Proxy
/// encrypted it. A passed-through statement stores plaintext, which the
/// `eql_v3_text_search` domain rejects, so this also proves that Proxy
/// mapped the statement instead of forwarding it unchanged.
async fn assert_stored_ciphertext(table: &str, id: i64, plaintext: &str) {
    let sql = format!("SELECT encrypted_text::text FROM {table} WHERE id = $1");
    let stored: Vec<String> = query_direct_by(&sql, &id).await;

    assert_eq!(stored.len(), 1, "expected exactly one row in {table}");
    assert_ne!(
        stored[0], plaintext,
        "value in {table}.encrypted_text was stored as plaintext"
    );
}

#[tokio::test]
async fn extended_protocol_ddl_reloads_schema_for_later_connections() {
    trace();

    let id = random_id();
    let table = format!("schema_reload_extended_{id}");
    let plaintext = "reload-after-extended-ddl".to_string();

    let ddl_client = connect_with_tls(*PROXY).await;

    // Extended protocol: Parse/Describe/Sync, then Bind/Execute/Sync.
    let sql = format!(
        "CREATE TABLE {table} (
            id bigint PRIMARY KEY,
            encrypted_text eql_v3_text_search
        )"
    );
    ddl_client.execute(&sql, &[]).await.unwrap();

    // Open the connection immediately, so that the 60-second background
    // reload cannot hide the defect.
    let client = connect_with_tls(*PROXY).await;

    let sql = format!("INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2)");
    let result = client.execute(&sql, &[&id, &plaintext]).await;

    assert!(
        result.is_ok(),
        "Proxy did not reload the schema after extended-protocol DDL, \
         so the new connection cannot map {table}.encrypted_text: {:?}",
        result.err()
    );

    assert_stored_ciphertext(&table, id, &plaintext).await;

    drop_table(&ddl_client, &table).await;
}

/// Control. The simple query protocol sends no `Sync`, so the frontend never
/// consumes the flag. The backend consumes it at `ReadyForQuery`, after the
/// DDL has run, and the reload therefore reads the new table.
///
/// This test passes before and after the fix. It fails if a fix removes the
/// reload instead of moving it.
#[tokio::test]
async fn simple_protocol_ddl_reloads_schema_for_later_connections() {
    trace();

    let id = random_id();
    let table = format!("schema_reload_simple_{id}");
    let plaintext = "reload-after-simple-ddl".to_string();

    let ddl_client = connect_with_tls(*PROXY).await;

    // Simple query protocol: one Query message, no Sync.
    let sql = format!(
        "CREATE TABLE {table} (
            id bigint PRIMARY KEY,
            encrypted_text eql_v3_text_search
        )"
    );
    ddl_client.simple_query(&sql).await.unwrap();

    let client = connect_with_tls(*PROXY).await;

    let sql = format!("INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2)");
    let result = client.execute(&sql, &[&id, &plaintext]).await;

    assert!(
        result.is_ok(),
        "Proxy did not reload the schema after simple-protocol DDL, \
         so the new connection cannot map {table}.encrypted_text: {:?}",
        result.err()
    );

    assert_stored_ciphertext(&table, id, &plaintext).await;

    drop_table(&ddl_client, &table).await;
}

assert_stored_ciphertext separates "the statement succeeded" from "Proxy mapped and encrypted the statement". A passthrough regression therefore cannot produce a green test.

Verification status, stated plainly: I verified the mechanism in the source, and the test compiles against d54fdfd6 (cargo check -p cipherstash-proxy-integration --tests). I could not run the test, because my local Docker has no free disk. Please run it before you act on this finding.

2. CI does not arm the memory gate

mise.toml:185, .github/workflows/test.yml:95

The task runs the soak without --max-rss-growth-mib. max_rss_growth_bytes is therefore None, and soak.rs:423-427 skips the gate. validate_report then checks only that operations > 0, that errors == 0, and that a live RSS sample exists.

The headline capability of this PR does not run in CI. The report is an artifact that somebody must read, not a gate.

The head run supplies the numbers to set a budget: rss_growth_bytes = 696320 (0.66 MiB over 30 seconds), and peak RSS 25 MiB. Please add --max-rss-growth-mib 16, or a similar value.

I raised this in review 4968785141. It is still open.


Should fix

3. A failed reload can hold a passthrough client for about 26 seconds

context/mod.rs:795-799, proxy/mod.rs:107-127, backend.rs:188

reload_schema_if_changed re-arms the flag when the reload fails. That behaviour is correct, and you tested it. The hazard is the combination of a blocking call site and a long retry ladder.

load_schema_with_retry retries 10 times, with backoff up to 2 seconds. That is about 13 seconds. proxy/mod.rs runs the schema loader and the encrypt-config loader in series, so a full failure takes about 26 seconds. The re-armed flag makes the next ReadyForQuery try again.

8661b7df puts this on the passthrough path (backend.rs:188). On main the passthrough path never reloaded. A bare-schema deployment under database connection pressure can therefore hold each ReadyForQuery for about 26 seconds, again and again, and the client sees no reason for the delay. All reloads pass through one global task, so one failing reload also delays every other connection.

Suggested fix: bound the retries at this call site, or make the passthrough reload non-blocking.

4. The report write can discard the real failure

packages/cipherstash-proxy-burn-in/src/soak.rs:119-124

write_report_atomic(...).await? runs before result?. If the write or the rename fails, the function returns the I/O error. report.terminal_error is lost with the struct, and the report file is stale or absent. CI uses if-no-files-found: warn, so the operator loses the cause on both channels.

preflight_output limits this to a failure during the run, such as a full disk. The exit code is still non-zero, so CI does not pass.

Suggested fix: log the terminal error before the write, or combine the two errors.


Nits

5. The encryption assertion does not cover the tables that the soak measures

database.rs:253-258, soak.rs:231-296

assert_seed_is_encrypted reads burnin_type_lab_samples id 1. The timed workload touches only the burnin_commerce_* tables. No at-rest ciphertext check covers a commerce column.

The encryption path is safe in practice. The eql_v3_text domain has a CHECK that rejects plaintext, and tokio_postgres rejects a String bind against the domain OID before it sends the value. A silent plaintext write cannot happen.

Two points remain:

  • The assertion is close to a tautology. The domain CHECK already guarantees the keys that database.rs:264-267 tests. The assertion can find fixture DDL drift only.
  • Head removed conformance from CI. conformance.rs is the only code that reads a commerce column back with typed decryption, so it is now dead in CI.

Suggested fix: assert ciphertext on one commerce row after the workload, and restore conformance to CI.

6. --help prints the fixture password

packages/cipherstash-proxy-burn-in/src/main.rs:33, :43

hide_env_values hides the environment value. It does not hide default_value. I confirmed this with --help:

--proxy-database-url <PROXY_DATABASE_URL>
    ... [env: BURN_IN_PROXY_DATABASE_URL] [default: postgresql://cipherstash:p%40ssword@localhost:6432/cipherstash]

The password is the committed local fixture credential (mise.toml:22-23), so --help discloses nothing new. But the PR body lists "redact connection credentials from help, diagnostics, and reports" as delivered. Redaction works for diagnostics and reports (DatabaseTarget, with a test at database.rs:299). It does not work for help.

Suggested fix: add hide_default_value = true, or move the defaults into a Default impl.

7. Duplicate conversion

backend.rs:187 and backend.rs:203 both compute code.into().


Withdrawn

I verified each of these against d54fdfd6 and withdraw it.

  • Sticky schema_changed flag. Fixed in bc94d74e. take_schema_changed uses swap(false, AcqRel), and two tests pin the behaviour. Finding 1 above replaces it.
  • migrate shares the 10-second per-operation timeout. Head uses MIGRATION_TIMEOUT. The head CI run leaves about 2.3 seconds for proxy start, migrate and teardown together, against a 10-second budget.
  • timeout-minutes: 15 is too tight. The job has no proxy:up step, so it builds Proxy once. Swatinem/rust-cache is present. Measured job times are 4m23s to 5m55s, and that range includes the first run, with a cold cache.
  • DROP DOMAIN ... CASCADE in tests/sql/eql-domains-uninstall.sql. This file is not in the PR. The PR changes no file under tests/sql/.
  • Nested cargo build deadlock. Not a hazard. The build lock is per profile, and target/debug/.cargo-lock and target/release/.cargo-lock are separate files. cargo run also releases the lock before it runs the binary. I confirmed this: a nested release build finished in 0.24 seconds while the outer binary was running.

@freshtonic
freshtonic merged commit 192fbb3 into main Aug 21, 2026
6 checks passed
@freshtonic
freshtonic deleted the feat/proxy-burn-in branch August 21, 2026 06:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants